import celery
import json
import rdflib
import requests
import requests_http_signature

from .. import activitypub
from .. import database
from .. import model
from .. import settings
from . import broker_url
from . import broker
from . import database_session

log = celery.utils.log.get_task_logger(__name__)
log.setLevel(settings.LOG_LEVEL)

@broker.task
def post(activity, recipient_uri, depth):
    """
    POST an Activity to a remote Actor. If the remote object is a Collection,
    a new task is scheduled for every item of that collection.
    If an error occurs during the HTTP request, the task is automatically
    rescheduled a number of times depending on the Celery configuration.
    
    IMPORTANT: This task is only responsible for the actual delivery (HTTP POST)
        of the Activity to the remote Actor's INBOX. This task will *not* attempt
        to perform any sort of validation of the Activity being sent. In other
        words: whoever is using this task should validate its own Activities
        before sending them.
    
    :param activity: The Activity to be sent.
    :type activity: dict
    
    :param recipient_uri: The URI of a remote actor to send the Activity to.
    
    :param depth: Number of indirections to follow if recipient_uri is a
        Collection. See the settings file for more info about this option.
    """
    
    with database_session() as (pagure_db, forgefed_db):
        
        actor_uri = activity['actor']
        
        # Make sure the Actor is not sending the Activity to itself.
        # https://www.w3.org/TR/activitypub/#delivery
        if actor_uri == recipient_uri:
            log.debug('Activity actor and recipient are the same. '
                      'Refuting to send.')
            return
        
        # If the Activity is addressed to as:Public, the Activity is not POSTed
        # to anyone.
        # https://www.w3.org/TR/activitypub/#public-addressing
        if recipient_uri == 'Public':
            log.debug('Not sending to as:Public.')
            return
        
        # Retrieve remote object (actor)
        remote_object = activitypub.fetch(recipient_uri)
        
        # Make sure we got an object, or abort task
        assert remote_object, 'Could not fetch remote actor.'
        
        # Select the Actor INBOX. Use sharedInbox if there is one.
        # https://www.w3.org/TR/activitypub/#sharedInbox
        if 'endpoints' in remote_object and 'sharedInbox' in remote_object['endpoints']:
            recipient_inbox = remote_object['endpoints']['sharedInbox']
        elif 'inbox' in remote_object:
            recipient_inbox = remote_object['inbox']
        else:
            recipient_inbox = None
        
        # If the remote object does not have an INBOX, we check if it's a
        # Collection, in which case we retrieve all its items.
        if not recipient_inbox:
            log.debug('Recipient is not an Actor. Checking if it\'s a '
                      'collection.')
            
            # Do not follow any more Collections.
            if depth < 1:
                log.debug('Max number of indirections reached. I will not '
                          'expand any more collections.')
                return
            
            if any(collection == remote_object['type'] for collection in
                   [ 'Collection', 'OrderedCollection',
                     'CollectionPage', 'OrderedCollectionPage' ]):
                
                items = []
                page  = []
                
                if 'items' in remote_object:
                    if isinstance(remote_object['items'], str):
                        items.append(remote_object['items'])
                    else:
                        items.extend(remote_object['items'])
                
                if 'orderedItems' in remote_object:
                    if isinstance(remote_object['orderedItems'], str):
                        items.append(remote_object['orderedItems'])
                    else:
                        items.extend(remote_object['orderedItems'])
                
                if 'first' in remote_object:
                    page.append(remote_object['first'])
                
                if 'next' in remote_object:
                    page.append(remote_object['next'])
                
                # Schedule a new delivery for every object found in the collection
                for recipient_uri in items:
                    post.delay(activity, recipient_uri, depth - 1)
                
                # TODO If a page "next" links to a previous page (which should
                #      not happen), this will not detect the loop.
                for recipient_uri in page:
                    post.delay(activity, recipient_uri, depth)
                
                if len(items) == 0 and len(page) == 0:
                    log.debug('Collection found, but it contains no items. '
                            + 'Activity will not be sent.')
            
            # Since this object does *not* have an INBOX, we stop here for this
            # task.
            return
        
        # Check if this Activity was already sent (successfully) to this INBOX.
        # If it was, we do not resend the same Activity twice. This situation
        # could happen with a sharedInbox, or if for some reasons the same
        # Activity is sent twice (maybe the job queue didn't remove it?).
        # NOTE The collection "recipient_inbox" is fictitious because we
        #      cannot have actual access to a remote Actor's INBOX.
        if forgefed_db.query(
               forgefed_db.query(database.Collection)
                          .filter(database.Collection.uri == recipient_inbox)
                          .filter(database.Collection.item == activity['id'])
                          .exists()
           ).scalar():
            
            log.warning(
                'Activity ' + activity['id'] + ' was already delivered to '
                'the INBOX ' + recipient_inbox + '. Not sending again.')
            
            return
        
        # Retrieve the private key of the local Actor for signing the HTTP request
        key = forgefed_db.query(database.GpgKey) \
                         .filter(database.GpgKey.actor_uri == actor_uri) \
                         .one_or_none()
        
        if not key:
            log.debug('Actor {} does not have a key. Cannot sign HTTP request.')
            return
        
        # This will add a "Signature:" header to the HTTP request.
        auth_method = requests_http_signature.HTTPSignatureHeaderAuth(
            key        = key.private,
            key_id     = key.uri,
            algorithm  = 'rsa-sha256',
            headers    = [ '(request-target)', 'host', 'date', 'digest' ])
            # passphrase = None,
            # expires_in = None)
        
        log.debug('Posting Activity ' + activity['id'] + ' to ' + recipient_inbox)
        log.debug(json.dumps(activity, indent=4, sort_keys=True))
        
        # Finally! Send out the Activity to the INBOX of the remote Actor
        response = requests.post(
            recipient_inbox,
            headers=activitypub.REQUEST_HEADERS,
            data=json.dumps(activity).encode('UTF-8'),
            auth=auth_method)
        
        log.debug('Activity ' + activity['id'] + ' POST return code: '
                  + str(response.status_code))
        
        # ActivityPub instances could return "200 OK" or "202 Accepted" upon
        # receiving the Activity.
        assert 200 <= response.status_code < 300
        
        log.debug('Activity posted without errors.')
        
        # Save in the database that this Activity was delivered successfully to
        # the remote Actor. This way it will not be resent twice (just in case).
        forgefed_db.add(database.Collection(
            uri = recipient_inbox,
            item = activity['id']))

@broker.task
def validate(actor_uri, activity):
    """
    This task is scheduled after receiving a new activity from a remote actor.
    It's called from the actor_receive() view in app.py.
    When a new Activity has been received the app will schedule a separate task
    for the Actor to handle it, and return immediately.
    
    :param actor_uri: URI of the Actor that has received the Activity
    :type actor_uri: str
    
    :param activity: The incoming activity document.
    :type activity: dict
    """
    
    if not actor_uri:
        log.info('Missing Actor. Ignoring task.')
        return
    
    if not activity:
        log.info('Missing Activity. Ignoring task.')
        return
    
    log.debug('Actor {} has received a new Activity with id {}:\n{}'.format(
        actor_uri, activity['id'], json.dumps(activity, indent=4, sort_keys=True)))
    
    with database_session() as (pagure_db, forgefed_db):
        
        # Recreate the actor class from its URI
        actor = model.from_uri(pagure_db, actor_uri)
        
        if not actor:
            log.debug('Actor {} doesn\'t exist. Ignoring incoming Activity.'.format(actor_uri))
            return
        
        # Check if this Activity was already delivered to this Actor. If it was,
        # we don't do anything since it was already processed in the past. New
        # activities should not have the same ID of older ones.
        if forgefed_db.query(forgefed_db \
                                .query(database.Collection) \
                                .filter(database.Collection.uri == actor.inbox_uri) \
                                .filter(database.Collection.item == activity['id']) \
                                .exists() \
                            ).scalar():
            
            log.debug('Activity already delivered to this actor.')
            return
        
        # Let's save a copy of the Activity in the database if it doesn't exist
        forgefed_db.merge(database.Activity(
            uri       = activity['id'],
            actor_uri = activity['actor'],
            document  = json.dumps(activity)))
        
        # Add the Activity to the Actor's INBOX
        forgefed_db.add(database.Collection(uri = actor.inbox_uri, item = activity['id']))
        
        #######################################################################
        # Now, we could stop here after the Activity has been added to the
        # Actor's INBOX, but Pagure is not just a server it also works
        # as a user client with an interface that allows user interactions.
        # For this reason we go on and process the Activity, which will likely
        # result in changes to the Pagure database.
        # The INBOX can also be read by other clients (eg. desktop apps).
        #######################################################################
        
        # Because JSON-LD can represent the same graph in several different
        # ways, we should probably normalize the JSONLD object before passing
        # it to the actor for processing. This simplifies working with the object.
        # Normalization could mean "flattening" or "compaction" of the JSONLD
        # document.
        # However, this step is left out for now and not implemented unless
        # needed because the ActivityStream specs already specifies that
        # objects should be served in compact form: https://www.w3.org/TR/social-web-protocols/#content-representation
        # 
        # activity = normalize_activity(activity)
        
        #actor.handle_incoming_activity(activity)
        handle_incoming_activity.delay(actor_uri, activity)

@broker.task
def handle_incoming_activity(actor_uri, activity):
    """
    An Actor has received a new Activity in its INBOX. After the incoming Activity
    has been validated (with the "validate" task), this task is scheduled in order
    to let the Actor deal with it.
    """
    
    if not actor_uri:
        log.info('Missing Actor. Ignoring task.')
        return
    
    if not activity:
        log.info('Missing Activity. Ignoring task.')
        return
    
    log.debug('Actor {} now handling Activity with id {}:\n{}'.format(
        actor_uri, activity['id'], json.dumps(activity, indent=4, sort_keys=True)))
    
    with database_session() as (pagure_db, forgefed_db):
        
        # Recreate the actor class from its URI
        actor = model.from_uri(pagure_db, actor_uri)
        
        if not actor:
            log.debug('Actor {} doesn\'t exist. Ignoring Activity.'.format(actor_uri))
            return
        
        actor.handle_incoming_activity(activity)
